<

ネストされたナビゲーション フローを作成する

アプリは時間の経過とともに数十、さらには数百のルートを蓄積します。 ルートの中には、トップレベル (グローバル) ルートとして意味のあるものもあります。 たとえば、「/」、「プロフィール」、「連絡先」、「ソーシャルフィード」はすべて アプリ内の可能なトップレベルのルート。 しかし、考えられるすべてのルートを トップレベルNavigatorウィジェット。リストは非常に長くなりますが、 そして、これらのルートの多くは、 別のウィジェット内でネストして処理する方が適切です。

ワイヤレスのモノのインターネット (IoT) セットアップ フローを検討します。 アプリで制御する電球。 このセットアップ フローは 4 ページで構成されます。 近くの電球を検索し、追加する電球を選択します。 電球を追加してセットアップを完了します。 この動作をトップレベルから調整できます。Navigatorウィジェット。ただし、2 番目を定義する方が合理的です。 入れ子になったNavigatorウィジェット内のSetupFlowウィジェット、 そしてネストさせますNavigator4 ページの所有権を取得します セットアップフローで。このナビゲーションの委任により、 ローカルコントロールの強化、つまり 一般に、ソフトウェアを開発する場合に推奨されます。

次のアニメーションはアプリの動作を示しています。

Gif showing the nested "setup" flow

このレシピでは、4 ページの IoT セットアップを実装します。 下にネストされた独自のナビゲーションを維持するフロー トップレベルのNavigatorウィジェット。

ナビゲーションの準備をする

この IoT アプリには 2 つのトップレベル画面があります。 セットアップの流れと一緒に。これらを定義します ルート名を定数として使用できるようにする コード内で参照できます。

const routeHome = '/';
const routeSettings = '/settings';
const routePrefixDeviceSetup = '/setup/';
const routeDeviceSetupStart = '/setup/$routeDeviceSetupStartPage';
const routeDeviceSetupStartPage = 'find_devices';
const routeDeviceSetupSelectDevicePage = 'select_device';
const routeDeviceSetupConnectingPage = 'connecting';
const routeDeviceSetupFinishedPage = 'finished';

ホーム画面と設定画面は次のように参照されます。 静的な名前。ただし、セットアップ フロー ページでは、 2 つのパスを使用してルート名を作成します。 ある/setup/プレフィックスの後に特定のページの名前が続きます。 2 つのパスを組み合わせることで、Navigator判断できる ルート名がセットアップ フローを対象としていることを示します。 に関連付けられたすべての個々のページを認識する セットアップの流れ。

トップレベルNavigator特定する責任はありません 個別のセットアップ フロー ページ。したがって、あなたのトップレベルは、Navigator受信ルート名を解析する必要がある セットアップ フローのプレフィックスを識別します。ルート名を解析する必要がある は使用できないことを意味しますroutes最上位のプロパティNavigator。代わりに、次の関数を提供する必要があります。onGenerateRoute財産。

埋め込むonGenerateRoute適切なウィジェットを返すには 3 つのトップレベルのパスごとに。

onGenerateRoute: (settings) {
  late Widget page;
  if (settings.name == routeHome) {
    page = const HomeScreen();
  } else if (settings.name == routeSettings) {
    page = const SettingsScreen();
  } else if (settings.name!.startsWith(routePrefixDeviceSetup)) {
    final subRoute =
        settings.name!.substring(routePrefixDeviceSetup.length);
    page = SetupFlow(
      setupPageRoute: subRoute,
    );
  } else {
    throw Exception('Unknown route: ${settings.name}');
  }

  return MaterialPageRoute<dynamic>(
    builder: (context) {
      return page;
    },
    settings: settings,
  );
},

ホームルートと設定ルートが正確に一致していることに注目してください。 路線名。ただし、設定された流路条件のみ プレフィックスをチェックします。ルート名にセットアップが含まれている場合 フロープレフィックスを追加した場合、ルート名の残りの部分は無視されます そして、に渡されましたSetupFlow処理するウィジェット。 このルート名の分割により、トップレベルのNavigatorさまざまなサブルートにとらわれないこと セットアップ フロー内で。

というステートフル ウィジェットを作成します。SetupFlowそれか ルート名を受け入れます。

class SetupFlow extends StatefulWidget {
  const SetupFlow({
    super.key,
    required this.setupPageRoute,
  });

  final String setupPageRoute;

  @override
  SetupFlowState createState() => SetupFlowState();
}

class SetupFlowState extends State<SetupFlow> {
  //...
}

セットアップフローのアプリバーを表示する

セットアップ フローでは永続的なアプリ バーが表示されます すべてのページに表示されます。

を返すScaffoldあなたのウィジェットSetupFlowウィジェットのbuild()方法、 希望するものを含めます2aaea5e-eb90-4504-8f41-880e940c08c3ウィジェット。

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: _buildFlowAppBar(),
    body: const SizedBox(),
  );
}

PreferredSizeWidget _buildFlowAppBar() {
  return AppBar(
    title: const Text('Bulb Setup'),
  );
}

アプリバーに戻る矢印が表示され、セットアップが終了します 戻る矢印を押すと流れます。しかし、 フローを終了すると、ユーザーはすべての進行状況を失います。 したがって、ユーザーは、次のことを行うかどうかを確認するように求められます。 セットアップフローを終了したい。

セットアップ フローを終了することをユーザーに確認するプロンプトを表示します。 ユーザーが次の操作を行ったときにプロンプ​​トが表示されることを確認します。 Android のハードウェアの戻るボタンを押します。

Future<void> _onExitPressed() async {
  final isConfirmed = await _isExitDesired();

  if (isConfirmed && mounted) {
    _exitSetup();
  }
}

Future<bool> _isExitDesired() async {
  return await showDialog<bool>(
          context: context,
          builder: (context) {
            return AlertDialog(
              title: const Text('Are you sure?'),
              content: const Text(
                  'If you exit device setup, your progress will be lost.'),
              actions: [
                TextButton(
                  onPressed: () {
                    Navigator.of(context).pop(true);
                  },
                  child: const Text('Leave'),
                ),
                TextButton(
                  onPressed: () {
                    Navigator.of(context).pop(false);
                  },
                  child: const Text('Stay'),
                ),
              ],
            );
          }) ??
      false;
}

void _exitSetup() {
  Navigator.of(context).pop();
}

@override
Widget build(BuildContext context) {
  return WillPopScope(
    onWillPop: _isExitDesired,
    child: Scaffold(
      appBar: _buildFlowAppBar(),
      body: const SizedBox(),
    ),
  );
}

PreferredSizeWidget _buildFlowAppBar() {
  return AppBar(
    leading: IconButton(
      onPressed: _onExitPressed,
      icon: const Icon(Icons.chevron_left),
    ),
    title: const Text('Bulb Setup'),
  );
}

ユーザーがアプリバーの戻る矢印をタップすると、 または Android の場合は戻るボタンを押します。 警告ダイアログがポップアップ表示され、 ユーザーがセットアップ フローを終了したいと考えています。 ユーザーが押した場合離れるそうすると、セットアップ フローが自動的に起動します。 最上位のナビゲーション スタックから。 ユーザーが押した場合止まるの場合、アクションは無視されます。

お気づきかもしれませんが、Navigator.pop()両方によって呼び出されます離れる止まるボタン。明確に言うと、 これpop()アクションにより警告ダイアログがポップアップ表示されます セットアップ フローではなく、ナビゲーション スタックです。

ネストされたルートを生成する

セットアップ フローの仕事は、適切な設定を表示することです。 フロー内のページ。

追加NavigatorウィジェットへSetupFlow、 そして実装してくださいonGenerateRoute財産。

final _navigatorKey = GlobalKey<NavigatorState>();

void _onDiscoveryComplete() {
  _navigatorKey.currentState!.pushNamed(routeDeviceSetupSelectDevicePage);
}

void _onDeviceSelected(String deviceId) {
  _navigatorKey.currentState!.pushNamed(routeDeviceSetupConnectingPage);
}

void _onConnectionEstablished() {
  _navigatorKey.currentState!.pushNamed(routeDeviceSetupFinishedPage);
}

@override
Widget build(BuildContext context) {
  return WillPopScope(
    onWillPop: _isExitDesired,
    child: Scaffold(
      appBar: _buildFlowAppBar(),
      body: Navigator(
        key: _navigatorKey,
        initialRoute: widget.setupPageRoute,
        onGenerateRoute: _onGenerateRoute,
      ),
    ),
  );
}

Route _onGenerateRoute(RouteSettings settings) {
  late Widget page;
  switch (settings.name) {
    case routeDeviceSetupStartPage:
      page = WaitingPage(
        message: 'Searching for nearby bulb...',
        onWaitComplete: _onDiscoveryComplete,
      );
      break;
    case routeDeviceSetupSelectDevicePage:
      page = SelectDevicePage(
        onDeviceSelected: _onDeviceSelected,
      );
      break;
    case routeDeviceSetupConnectingPage:
      page = WaitingPage(
        message: 'Connecting...',
        onWaitComplete: _onConnectionEstablished,
      );
      break;
    case routeDeviceSetupFinishedPage:
      page = FinishedPage(
        onFinishPressed: _exitSetup,
      );
      break;
  }

  return MaterialPageRoute<dynamic>(
    builder: (context) {
      return page;
    },
    settings: settings,
  );
}

_onGenerateRoute関数は次と同じように動作します トップレベルの場合Navigator。あRouteSettingsオブジェクトが関数に渡されると、 これにはルートが含まれますname。 その路線名からすると、 4 つのフロー ページのうちの 1 つが返されます。

最初のページと呼ばれるfind_devices、 ネットワーク スキャンをシミュレートするために数秒待ちます。 待機期間の後、ページはコールバックを呼び出します。 この場合、そのコールバックは_onDiscoveryComplete。 セットアップ フローは、デバイスの検出時にそれを認識します。 完了すると、デバイス選択ページが表示されます。 したがって、_onDiscoveryComplete_navigatorKeyネストされたものに指示しますNavigatorに移動するにはselect_deviceページ。

select_deviceページはユーザーに選択を求めます 利用可能なデバイスのリストからデバイスを選択します。このレシピでは、 ユーザーに提示されるデバイスは 1 つだけです。 ユーザーがデバイスをタップすると、onDeviceSelectedコールバックが呼び出されます。セットアップ フローでは次のことが認識されます。 デバイスを選択すると、接続ページが表示されます 示されるべきである。したがって、_onDeviceSelected、 の_navigatorKeyネストされたものに指示しますNavigatorに移動するには”connecting”ページ。

connectingページは と同じように機能しますfind_devicesページ。のconnectingページ待機 数秒間呼び出した後、コールバックを呼び出します。 この場合、コールバックは次のようになります。_onConnectionEstablished。 セットアップ フローは、接続が確立されると、 最後のページが表示されるはずです。したがって、 の_onConnectionEstablished_navigatorKeyネストされたものに指示しますNavigatorに移動するにはfinishedページ。

finishedページはユーザーに終了ボタン。ユーザーがタップすると終了、 の_exitSetupコールバックが呼び出され、全体がポップされます。 トップレベルからのセットアップ フローNavigatorスタック、 ユーザーをホーム画面に戻します。

おめでとう! 4 つのサブルートを含むネストされたナビゲーションを実装しました。

インタラクティブな例

アプリを実行します。

  • 最初の電球を追加します画面、 プラス記号で表示されている FAB をクリックします。+。 これにより、近くのデバイスを選択してください画面。単一の電球がリストされています。
  • リストされている電球をクリックします。あ終了した!画面が表示されます。
  • クリック終了したに戻るボタン 最初の画面。
DartPadを開始する
import 'package:flutter/material.dart';

const routeHome = '/';
const routeSettings = '/settings';
const routePrefixDeviceSetup = '/setup/';
const routeDeviceSetupStart = '/setup/$routeDeviceSetupStartPage';
const routeDeviceSetupStartPage = 'find_devices';
const routeDeviceSetupSelectDevicePage = 'select_device';
const routeDeviceSetupConnectingPage = 'connecting';
const routeDeviceSetupFinishedPage = 'finished';

void main() {
  runApp(
    MaterialApp(
      theme: ThemeData(
        brightness: Brightness.dark,
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.blue,
        ),
        floatingActionButtonTheme: const FloatingActionButtonThemeData(
          backgroundColor: Colors.blue,
        ),
      ),
      onGenerateRoute: (settings) {
        late Widget page;
        if (settings.name == routeHome) {
          page = const HomeScreen();
        } else if (settings.name == routeSettings) {
          page = const SettingsScreen();
        } else if (settings.name!.startsWith(routePrefixDeviceSetup)) {
          final subRoute =
              settings.name!.substring(routePrefixDeviceSetup.length);
          page = SetupFlow(
            setupPageRoute: subRoute,
          );
        } else {
          throw Exception('Unknown route: ${settings.name}');
        }

        return MaterialPageRoute<dynamic>(
          builder: (context) {
            return page;
          },
          settings: settings,
        );
      },
      debugShowCheckedModeBanner: false,
    ),
  );
}

@immutable
class SetupFlow extends StatefulWidget {
  static SetupFlowState of(BuildContext context) {
    return context.findAncestorStateOfType<SetupFlowState>()!;
  }

  const SetupFlow({
    super.key,
    required this.setupPageRoute,
  });

  final String setupPageRoute;

  @override
  SetupFlowState createState() => SetupFlowState();
}

class SetupFlowState extends State<SetupFlow> {
  final _navigatorKey = GlobalKey<NavigatorState>();

  @override
  void initState() {
    super.initState();
  }

  void _onDiscoveryComplete() {
    _navigatorKey.currentState!.pushNamed(routeDeviceSetupSelectDevicePage);
  }

  void _onDeviceSelected(String deviceId) {
    _navigatorKey.currentState!.pushNamed(routeDeviceSetupConnectingPage);
  }

  void _onConnectionEstablished() {
    _navigatorKey.currentState!.pushNamed(routeDeviceSetupFinishedPage);
  }

  Future<void> _onExitPressed() async {
    final isConfirmed = await _isExitDesired();

    if (isConfirmed && mounted) {
      _exitSetup();
    }
  }

  Future<bool> _isExitDesired() async {
    return await showDialog<bool>(
            context: context,
            builder: (context) {
              return AlertDialog(
                title: const Text('Are you sure?'),
                content: const Text(
                    'If you exit device setup, your progress will be lost.'),
                actions: [
                  TextButton(
                    onPressed: () {
                      Navigator.of(context).pop(true);
                    },
                    child: const Text('Leave'),
                  ),
                  TextButton(
                    onPressed: () {
                      Navigator.of(context).pop(false);
                    },
                    child: const Text('Stay'),
                  ),
                ],
              );
            }) ??
        false;
  }

  void _exitSetup() {
    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: _isExitDesired,
      child: Scaffold(
        appBar: _buildFlowAppBar(),
        body: Navigator(
          key: _navigatorKey,
          initialRoute: widget.setupPageRoute,
          onGenerateRoute: _onGenerateRoute,
        ),
      ),
    );
  }

  Route _onGenerateRoute(RouteSettings settings) {
    late Widget page;
    switch (settings.name) {
      case routeDeviceSetupStartPage:
        page = WaitingPage(
          message: 'Searching for nearby bulb...',
          onWaitComplete: _onDiscoveryComplete,
        );
        break;
      case routeDeviceSetupSelectDevicePage:
        page = SelectDevicePage(
          onDeviceSelected: _onDeviceSelected,
        );
        break;
      case routeDeviceSetupConnectingPage:
        page = WaitingPage(
          message: 'Connecting...',
          onWaitComplete: _onConnectionEstablished,
        );
        break;
      case routeDeviceSetupFinishedPage:
        page = FinishedPage(
          onFinishPressed: _exitSetup,
        );
        break;
    }

    return MaterialPageRoute<dynamic>(
      builder: (context) {
        return page;
      },
      settings: settings,
    );
  }

  PreferredSizeWidget _buildFlowAppBar() {
    return AppBar(
      leading: IconButton(
        onPressed: _onExitPressed,
        icon: const Icon(Icons.chevron_left),
      ),
      title: const Text('Bulb Setup'),
    );
  }
}

class SelectDevicePage extends StatelessWidget {
  const SelectDevicePage({
    super.key,
    required this.onDeviceSelected,
  });

  final void Function(String deviceId) onDeviceSelected;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                'Select a nearby device:',
                style: Theme.of(context).textTheme.titleLarge,
              ),
              const SizedBox(height: 24),
              SizedBox(
                width: double.infinity,
                height: 54,
                child: ElevatedButton(
                  style: ButtonStyle(
                    backgroundColor: MaterialStateColor.resolveWith((states) {
                      return const Color(0xFF222222);
                    }),
                  ),
                  onPressed: () {
                    onDeviceSelected('22n483nk5834');
                  },
                  child: const Text(
                    'Bulb 22n483nk5834',
                    style: TextStyle(
                      fontSize: 24,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class WaitingPage extends StatefulWidget {
  const WaitingPage({
    super.key,
    required this.message,
    required this.onWaitComplete,
  });

  final String message;
  final VoidCallback onWaitComplete;

  @override
  State<WaitingPage> createState() => _WaitingPageState();
}

class _WaitingPageState extends State<WaitingPage> {
  @override
  void initState() {
    super.initState();
    _startWaiting();
  }

  Future<void> _startWaiting() async {
    await Future<dynamic>.delayed(const Duration(seconds: 3));

    if (mounted) {
      widget.onWaitComplete();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const CircularProgressIndicator(),
              const SizedBox(height: 32),
              Text(widget.message),
            ],
          ),
        ),
      ),
    );
  }
}

class FinishedPage extends StatelessWidget {
  const FinishedPage({
    super.key,
    required this.onFinishPressed,
  });

  final VoidCallback onFinishPressed;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: 250,
                height: 250,
                decoration: const BoxDecoration(
                  shape: BoxShape.circle,
                  color: Color(0xFF222222),
                ),
                child: const Center(
                  child: Icon(
                    Icons.lightbulb,
                    size: 175,
                    color: Colors.white,
                  ),
                ),
              ),
              const SizedBox(height: 32),
              const Text(
                'Bulb added!',
                textAlign: TextAlign.center,
                style: TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                ),
              ),
              const SizedBox(height: 32),
              ElevatedButton(
                style: ButtonStyle(
                  padding: MaterialStateProperty.resolveWith((states) {
                    return const EdgeInsets.symmetric(
                        horizontal: 24, vertical: 12);
                  }),
                  backgroundColor: MaterialStateColor.resolveWith((states) {
                    return const Color(0xFF222222);
                  }),
                  shape: MaterialStateProperty.resolveWith((states) {
                    return const StadiumBorder();
                  }),
                ),
                onPressed: onFinishPressed,
                child: const Text(
                  'Finish',
                  style: TextStyle(
                    fontSize: 24,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

@immutable
class HomeScreen extends StatelessWidget {
  const HomeScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: _buildAppBar(context),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: 250,
                height: 250,
                decoration: const BoxDecoration(
                  shape: BoxShape.circle,
                  color: Color(0xFF222222),
                ),
                child: Center(
                  child: Icon(
                    Icons.lightbulb,
                    size: 175,
                    color: Theme.of(context).scaffoldBackgroundColor,
                  ),
                ),
              ),
              const SizedBox(height: 32),
              const Text(
                'Add your first bulb',
                textAlign: TextAlign.center,
                style: TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ],
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.of(context).pushNamed(routeDeviceSetupStart);
        },
        child: const Icon(Icons.add),
      ),
    );
  }

  PreferredSizeWidget _buildAppBar(BuildContext context) {
    return AppBar(
      title: const Text('Welcome'),
      actions: [
        IconButton(
          icon: const Icon(Icons.settings),
          onPressed: () {
            Navigator.pushNamed(context, routeSettings);
          },
        ),
      ],
    );
  }
}

class SettingsScreen extends StatelessWidget {
  const SettingsScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: _buildAppBar(),
      body: SingleChildScrollView(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: List.generate(8, (index) {
            return Container(
              width: double.infinity,
              height: 54,
              margin: const EdgeInsets.only(left: 16, right: 16, top: 16),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(8),
                color: const Color(0xFF222222),
              ),
            );
          }),
        ),
      ),
    );
  }

  PreferredSizeWidget _buildAppBar() {
    return AppBar(
      title: const Text('Settings'),
    );
  }
}